package com.weiss.jpro.utils;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.weiss.jpro.entity.Shop;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

@Slf4j
// 交给spring管理
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    // 基于构造函数注入
    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    // 设置过期时间，传入时间和单位
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    // 设置逻辑过期时间，传入时间和单位
    public void setWithLogicExpire(String key, Object value, Long time, TimeUnit unit) {
        // 添加逻辑过期字段
        RedisData redisData = new RedisData();
        redisData.setData(value);
        // 将当前时间加上传入时间，得到逻辑过期时间，unit.toSeconds(time)得到当前时间到过期时间的秒数
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // 写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    // 封装工具类的难点
    // 1：在查询的时候，由于返回值的类型和id类型不确定，
    // 要善于利用泛型在数据类型不确定的情况下指定对应的类型，
    // 由调用者告诉我们真实的类型是什么，从而做出泛型的推断
    // 2：封装查询逻辑时，牵扯到数据库查询
    // 但是不知道如何查询数据库，由调用我们的人告诉我们数据库该如何查询，
    // 查数据库是一段函数，所以传递的参数是一个函数，用到了函数式编程
    // 查询函数是根据id查数据库，有参数有返回值，对应java中的Function<T, R>
    // 用Function<ID, R>指定参数和返回值的类型，在调用的时候告诉我们就行了

    // 工具类，返回类型不确定，使用泛型，类型不确定都使用泛型
    public <R, ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        // 1.key
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);

        // 2.判断redis是否命中
        if (StrUtil.isNotBlank(json)) {
            // 存在，直接返回
            R result = JSONUtil.toBean(json, type);
            log.debug("redis命中");
            return result;
        }

        // 2.5 判断命中的是否是空字符串，前面已经判断有值，只剩下null和""，不是null就是空字符串
        // 上面2.通过isNotBlank判断是否有值，无值还存在null和""的情况
        if (json != null) {
            // redis命中之前存的空字符串
            // 避免大量使用不存在店铺信息访问数据库
            log.debug("请不求在id，redis命中空字符串");
            return null;
        }

        // 3.不存在，根据id从数据库查询
        // 不知道传入类型，所以不知道查询逻辑，需要在传入参数中指定
        // 在参数中添加数据库查询逻辑，函数式编程可以传递
        // 传入有参有返回值的函数，Function<ID, R>
        R result = dbFallback.apply(id);
        log.debug("redis未命中");

        // 4.不存在返回错误
        if (result == null) {
            // 4.5 将空字符串写入redis，存储不存在的店铺信息缓存""，设置TTL为2分钟->回到2.5编写对应处理逻辑
            stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
            log.debug("使用不存在id进行访问，存储空字符串到redis");
            return null;
        }

        // 5.存在，写入redis并返回数据
        // 缓存更新策略，先写数据库再删除缓存；设置超时剔除
        // 上面已经实现set方法，直接调用
        this.set(key, result, time, unit);
//        String Json = JSONUtil.toJsonStr(result);
//        stringRedisTemplate.opsForValue().set(key, Json, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); // 超时剔除
        return result;
    }

    // 创建线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    // 获取锁
    private boolean tryLock(String lock) {
        // 利用setnx是实现互斥锁，获取锁setnx lock ex 10s, 释放锁del lock
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lock, "1", 10, TimeUnit.SECONDS);
        // 需要把包装类型转成基本类型返回，直接返回自动拆箱有可能存在空指针，使用hutool工具类手动拆箱
        return BooleanUtil.isTrue(flag);
    }
    // 释放锁
    private void unLock(String lock) {
        stringRedisTemplate.delete(lock);
    }

    // 封装工具类解决缓存击穿
    public <R, ID> R queryWithLogicExpire(
            String keyPreFix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        // 1.从redis查询商户缓存
        String key = keyPreFix + id;
        String json = stringRedisTemplate.opsForValue().get(key);

        // 2.判断redis是否命中
        if (StrUtil.isBlank(json)) {
            // 未命中，直接返回
            log.debug("redis未命中，不是热点key");
            return null;
        }

        // 3命中，判断过期时间
        // 反序列化json
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        // 不知道是什么类型会转成JSONObject, RedisData里面是Object
        JSONObject data = (JSONObject) redisData.getData();
        // toBean还可以接收JSONObject
        R result = JSONUtil.toBean(data, type);
        // 获取过期时间
        LocalDateTime expireTime = redisData.getExpireTime();
        // 判断是否过期 // 过期时间是否在当前时间之后
        if (expireTime.isAfter(LocalDateTime.now())) {
            //3.1 未过期，返回店铺信息
            log.debug("未过期，返回店铺信息");
            return result;
        }

        //3.2 已过期，需要缓存重建
        //4.缓存重建
        //4.1 获取互斥锁
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 注意：获取锁成功应该再次检测redis缓存是否过期，做doublecheck，如果存在则无需重建缓存
        // 避免缓存重建释放锁的瞬间另一个线程拿到锁，再次重建缓存
        // 例：测试时将并发数量调到2000以上就会重建缓存多次
        // 这里的锁不是线程锁，是redis的锁，所有线程都可以删除，所以可以新建线程释放锁
        // 这里加锁是为了只有一个线程开启子线程去重建缓存
        if (isLock) {
            //4.2 成功，开启独立线程，实现缓存重建
            // 通过线程池来进行，避免经常创建和销毁
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 重建缓存
                    log.debug("热点key过期，进行缓存重建");
                    // 查询数据库
                    R res = dbFallback.apply(id);
                    // 写入redis
                    this.setWithLogicExpire(key, res, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }

        //4.3 失败还是失败，都会返回过期信息
        log.debug("返回过期信息");
        return result;
    }

}
